Skip to content

feat(frontdoor): git-push webhook — the fail-closed trigger that deploys - #38

Merged
mdheller merged 2 commits into
mainfrom
feat/push-webhook
Aug 4, 2026
Merged

feat(frontdoor): git-push webhook — the fail-closed trigger that deploys#38
mdheller merged 2 commits into
mainfrom
feat/push-webhook

Conversation

@mdheller

@mdheller mdheller commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

deploy_flow.on_push was the build→deploy→preview flow, but nothing turned a real git push into a deploy. This adds that trigger: a governed webhook receiver the git host (Gitea/GitHub) POSTs to on every push. It closes the last named gap in docs/BUILD_DEPLOY.md"the only remaining piece is the push trigger" — so git push now literally deploys.

Fail-closed at the door

An unsigned or mis-signed push is NEVER built and NEVER deployed. Every git host signs deliveries (GitHub X-Hub-Signature-256: sha256=…, Gitea X-Gitea-Signature: …); we verify with a constant-time HMAC-SHA256 over the raw body against the project's per-tenant secret before any work starts. The trigger itself is a zero-trust gate, not an open hook.

POST /hooks/<tenant>/<app>
  ──▶ verify HMAC (constant-time)      ← unsigned/forged ⇒ REJECTED (401), no build
  ──▶ parse push event                 ← tags & branch-deletes ⇒ ignored
  ──▶ resolve source tree @ pushed SHA (injected checkout)
  ──▶ deploy_flow.on_push()  ──▶ build ──▶ per-branch preview
  ──▶ SEALED receipt, bound to the exact body digest   ← secret never echoed

Design

  • tools/push_webhook.py — a pure, unit-tested core (verify_signature / parse_push_event / handle_push) + a thin stdlib http.server wrapper. No dependencies.
  • The project secret is never sealed, echoed, or written — the sealed receipt carries only the body digest + event summary.
  • The full source manifest at the pushed commit is resolved by an injected resolve_files(repo, ref, after) callback (production: a checkout of the pushed SHA); it falls back to the payload's changed files, documented.
  • Kept off the read-only portal by design — a mutating trigger belongs on its own governed receiver, not the GET-only console.

Wired in

  • capd/git-push-webhook.mesh.capd.json (caps.dev.git-push-webhook, composes_with git-push-deploy)
  • validate.py (required tool + CapD), Makefile push-webhook
  • portal evidence view now surfaces webhook-receipts
  • docs/BUILD_DEPLOY.md documents the trigger

Tests

+14 = 196 tools tests green. Signed push deploys; unsigned / forged-signature / body-tampered-after-signing all rejected and never built; tag & branch-delete ignored; resolve_files override detects the real buildpack; receipt sealed + persisted + body-bound; secret never leaked into decision or receipt.

🤖 Generated with Claude Code

…oys (#37)

deploy_flow.on_push is the build->deploy->preview flow, but nothing turned a real `git push` into a
deploy. This is that trigger: a governed webhook receiver the git host (Gitea/GitHub) POSTs to on
every push. It closes the last named gap in BUILD_DEPLOY.md ("the only remaining piece is the push
trigger") — `git push` now literally deploys.

It is FAIL-CLOSED at the door: an unsigned or mis-signed push is NEVER built and NEVER deployed.

tools/push_webhook.py — a pure, unit-tested core + a thin stdlib http.server wrapper:
  - verify_signature: constant-time HMAC-SHA256 over the RAW body; accepts GitHub
    `X-Hub-Signature-256: sha256=…` and Gitea `X-Gitea-Signature: …` (bare hex). Empty secret/body/
    header => False.
  - parse_push_event: normalises the git-host push payload (branch vs tag, delete, changed files).
  - handle_push: verify FIRST; a bad signature returns `rejected` with NO build started. Tags/deletes
    are ignored. The full source tree at the pushed SHA is resolved by an injected checkout callback
    (falls back to the payload's changed files, documented). Emits a SEALED, tamper-evident receipt
    bound to the exact body digest — the project secret is never sealed, echoed, or written.
  - serve(): POST /hooks/<tenant>/<app>, per-tenant secret from the sovereign store; 401 rejected /
    202 accepted / 422 build-failed. GET only /healthz.

Same fail-closed posture as the rest of the stack: the trigger itself is a zero-trust gate, not an
open hook — the sovereign answer to an anonymous deploy webhook.

Wired: capd/git-push-webhook.mesh.capd.json (caps.dev.git-push-webhook, composes_with git-push-deploy);
validate.py (required tool + CapD); Makefile `push-webhook`; portal evidence view surfaces
webhook-receipts; docs/BUILD_DEPLOY.md documents the trigger.

Tests: +14 = 196 tools tests green (signed deploys, unsigned/forged/body-tampered rejected & never
built, tag/delete ignored, resolve_files override, sealed+persisted receipt, secret never leaked).
…shing

A push whose HMAC verifies but whose body is a JSON non-object ([], 123, "x", null) reached
parse_push_event and would raise on .get() — a 500 on authenticated-but-malformed input. handle_push
now rejects it cleanly (same as non-JSON), and parse_push_event accesses nested fields defensively
(_obj() + isinstance-guarded commits). +1 test = 197 green. Handle any input; never crash the door.
@mdheller
mdheller merged commit 841bf47 into main Aug 4, 2026
1 check passed
@mdheller
mdheller deleted the feat/push-webhook branch August 4, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant